Skip to content

Add Contact Center module - #501

Open
MikeAlhayek wants to merge 295 commits into
mainfrom
ma/add-contact-center
Open

Add Contact Center module#501
MikeAlhayek wants to merge 295 commits into
mainfrom
ma/add-contact-center

Conversation

@MikeAlhayek

Copy link
Copy Markdown
Member

No description provided.

Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs Outdated
Comment thread src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs Outdated
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

MikeAlhayek and others added 3 commits July 9, 2026 22:09
…conference state

- TelephonyHub: guard completion-log LogInformation calls with IsEnabled to resolve 5 CA1873 warnings (clean -warnaserror build)
- ActivityReservationService.ReserveAsync: re-read and re-validate the agent (compare-and-set on ActiveReservationId) before booking so a concurrent assignment on another shared queue cannot double-book the agent
- Asterisk dashboard: report 'In conference' for bridged 3+ party calls instead of collapsing to 'Connected'
- Add self-healing test (stale Ringing interaction requeued) and reserve double-book guard test
- Update contact-center PLAN.md change log with review findings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a71f4e1c-3ca9-4e08-8d68-81a75d79e03d
Make provider events durable and terminal-safe, serialize assignment and reconciliation races, and improve Asterisk and DialPad recovery behavior. Add regression coverage and update Contact Center documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
@github-actions

Copy link
Copy Markdown

This pull request has merge conflicts. Please resolve those before requesting a review.

MikeAlhayek and others added 8 commits July 10, 2026 14:32
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Inbound calls whose provider channel no longer exists could keep being
offered and accepted, leaving the agent stuck (unable to disconnect,
unable to receive new calls). Make the telephony provider the source of
truth for offer validity and cleanup:

- ProviderVoiceEventService seeds a newly created call session with the
  interaction's pre-event (non-terminal) state instead of the incoming
  provider state, so a first-observed terminal event (e.g. a reconcile
  of an already-gone queued call) still records a non-terminal to
  terminal transition, publishes CallEnded, and runs ended-offer cleanup
  instead of silently leaving the interaction queued.
- VoiceContactCenterCallRouter.OfferNextAsync refreshes each queued
  interaction from provider truth in a bounded loop and, when the
  provider confirms the call is gone, removes it from the queue and
  releases the reservation and agent via ReconcileEndedOfferAsync before
  offering the next call, so a dead call is never offered.
- ContactCenterCallCommandService accept-media failures now re-check
  provider truth and reconcile a confirmed-gone call instead of relying
  on a CancelAsync no-op that left an accepted reservation stuck.

Adds 3 unit tests (1,279 total pass); clean -warnaserror build. Updates
voice-routing docs, v2.0.0 changelog, and the Contact Center plan.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Fixes recurring inbound-queue zombies, stuck soft-phone "In Call" state,
and dropped real-time events by repairing the event-delivery pipeline and
self-healing triggers so the telephony provider stays the source of truth.

- Asterisk live listener isolates each event dispatch (log+skip on malformed
  payloads, unroutable events, or transient tenant-scope failures during shell
  reload) instead of tearing down the WebSocket and dropping subsequent events;
  it now also subscribes to all application events (subscribeAll).
- Ended-offer reconciliation cancels every non-terminal reservation bound to an
  activity and clears stale agent reservation pointers, even for answered calls.
- Work-state healer never requeues a provider-backed ringing interaction; live
  ringing is released only when provider truth confirms it ended.
- Manual presence changes self-heal against provider truth so an agent stuck
  Busy after a provider-ended call can return to Available immediately, while a
  genuinely live provider-backed call is preserved.
- Adds ListActiveByActivityAsync across reservation store/manager.
- 8 new unit tests; docs and changelog updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95
Add permission-gated activity purge, configurable follow-up ownership, and national/E.164 phone search.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c
Reuse the shared contact index for version-aware phone searches, record purge audit metadata, and move assignment toggling into a registered JavaScript resource.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c
Store national digits in the primary phone columns, retain E.164 in normalized columns, and migrate existing v8 indexes to the consolidated v9 schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b264ef76-66ac-4c60-a65c-1ebc6a664121
Repair stale provider call state, preserve agent capacity across pre-connect endings, improve routing diagnostics, correct soft-phone selectors, and restore Asterisk dashboard SignalR updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b264ef76-66ac-4c60-a65c-1ebc6a664121
MikeAlhayek and others added 30 commits August 1, 2026 05:39
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…actor)

Add EnterpriseInteractionReportConcurrencyTests proving the only report
provider with mutable per-execution fields (EnterpriseInteractionReportProvider)
cannot leak state across requests: a scoped-lifetime registration assertion
(runs the real AnalyticsStartup.ConfigureServices and asserts every IReport is
ServiceLifetime.Scoped) closes the cross-request shape structurally, and a
real-SQLite reuse test proves RunAsync re-captures agent/capability state each
call. No production change per the plan's prove-before-refactoring directive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
BuildIdempotencyKey hashed the raw payload bytes, so a whitespace/property-order
or numeric-token change from an Asterisk upgrade or a re-serializing proxy would
silently defeat deduplication and cause double-processing. ARI events carry no
eventId/sequence, so the key is now built from a canonical form of the payload:
object properties are ordered, arrays preserve order, and numeric tokens are
normalized (Int64/UInt64 exact, else shortest round-trippable double), then
SHA-256 hashed and prefixed with provider + event type. Re-serialized redeliveries
of one event dedupe; two genuinely distinct same-type events on one call (differing
in timestamp/varset value/sub-state) keep distinct keys and are both processed.

Adds mapper tests covering property-order re-serialization, numeric re-serialization,
two distinct same-type events on one call, and the provider:eventType key prefix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…guard

Verification confirms the live CallSession topology is fully typed (Bridge,
ConsultCall, CallLeg, CallRelationship, MonitorSession; ConsultCall.TargetType is
the typed InteractionTransferTargetType) and MediaTopologyId is gone from all
production source. The only historical transfer strings are the audit-only
InteractionTransferHistoryEntry.TargetType/.Result fields, which no neutral
consumer parses back into typed values (reports only count them or group them for
display). Keeps the fields as string (retyping would break the persisted YesSql
JSON of existing interactions).

- Clarifies the XML docs on the two audit fields as historical text snapshots
  that are never re-parsed.
- Adds ContactCenterTransferTopologyTypingArchitectureTests pinning: live consult
  topology uses the typed enum; no live topology model exposes MediaTopologyId or
  a string TargetType; MediaTopologyId stays absent from production source.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…active-process constraint (B6)

- AsteriskAriApplicationOwnershipRegistry.TryClaim now fails closed: blank
  baseUrl/applicationName/tenantName/ownershipToken are denied and logged,
  instead of silently granting a blank claim.
- Inject ILogger into the registry; update the interface XML doc contract.
- Log the single-active-process ownership constraint at startup when the
  Asterisk real-time voice listener starts.
- Document the non-overlapping deployment requirement in the runbook.
- Update ownership/gate tests for the new constructor and deny-on-blank
  behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Bound each run to a wall-clock budget (90s) enforced by both an IClock
  between-operations deadline check and a hard CancelAfter on a linked
  CancellationTokenSource, keeping the run below the 120s distributed-lock
  expiration so a slow run cannot outlive its lock and overlap the next tick.
- Participate in the Routing feature work-admission drain via TryEnter and a
  matching ContactCenterFeatureWorkLifecycleParticipant registration.
- Propagate shutdown cancellation instead of swallowing it; treat deadline
  cancellation as a graceful defer-to-next-tick.
- Observe cancellation between per-item lock acquisitions in
  ActivityReservationService.ExpireDueAsync so contended expiry cannot run
  unbounded past the task lock.
- Update feature-lifecycle contract ledger to implemented-r3 and add tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ReadDateTimeOffset now parses with CultureInfo.InvariantCulture and
DateTimeStyles.AssumeUniversal | AdjustToUniversal so offset-less provider
timestamps are treated as UTC instead of the server's local time zone, and
offset-bearing values normalize to UTC. Exposed the helper as internal for
direct unit testing across cultures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements release-blocking item C1: end-to-end recording media erasure and
retention lifecycle for the Contact Center recording feature.

- Add IRecordingErasureGuard + RecordingErasureGuard that reads the erasure
  tombstone through a fresh child scope (IContactCenterScopeExecutor) so the
  Asterisk ingest sees committed erasures and never orphans media in the
  post-store recheck window.
- Harden Asterisk ingest cancellation to delete media by the deterministic
  storage key (MediaReference ?? RecordingName) regardless of MediaStored,
  closing the crash-window orphan; deletion is idempotent.
- Make retention batches atomic: on a batch failure the shared session is
  reset (ISession.ResetAsync) so no partial staged work — e.g. a
  RecordingErased event without its outbox message — can be committed by a
  later entity's flush and then suppressed by the idempotency key, which would
  orphan media. Successful batches still commit and other entities still drain.
- Add indexed InteractionIndex.RecordingLegalHold (+ provider mapping +
  UpdateFrom4 migration) and exclude held rows from the retention predicate so
  held recordings never enter a purge batch; keep the per-record veto as a
  safety net.
- Clear the CallSession recording mirror on every erasure path (including the
  no-recording deny and empty-reference branches) so no stray handle can
  resurrect access to deleted media.
- Add RecordingMediaDeletionHandler, audit-trail handler, tenant media purge
  (ISupportsTenantMediaPurge), erasure endpoint, and the RecordingMediaDeleted
  event, with docs, changelog, tests, and public API baselines.

gpt-5.6-sol GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
W17 in the release-authoritative ledger (PLAN-3) and the C1 checkbox in
PRODUCTION-READINESS.md are marked [x] with a resolvable gate annotation
(pr_ci.yml#build_test, release_ci.yml#distributed_test) and truthful
evidence. Adds the Wave 6 verified-baseline run row and a C1 progress-log
entry. Governance/ledger suite: 110 pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Widen the CallSession provider-call identifier (128->256) and the claim
key composed from it (261->385) via a portable in-place rebuild, so an
external switch's long call identifier (e.g. a SIP Call-ID) is stored in
full on engines that enforce a declared length rather than having its
write rejected (PostgreSQL 22001 / SQL Server 8152 / strict MySQL 1406)
or, on non-strict MySQL, silently truncated into a forged claim-key
collision.

Because SQLite has no ALTER COLUMN, the widening is an add-copy-drop-
rename rebuild via a new reusable IndexStringColumnRebuild.WidenAsync
helper, with the unique claim index and the covering index dropped first
and recreated after. Blockers fixed:

- B1: on SQL Server only, drop the auto-named default constraint before
  DropColumn (Msg 5074); read the name from sys.default_constraints via a
  parameterized lookup. The additive-migration guard gains a raw-SQL
  in-place-rebuild entry using an "(anonymous)" restored-object sentinel
  for the engine-generated constraint, scoped strictly to raw-SQL entries.
- B2: unroll the tolerant multi-DropIndex foreach into one tolerant
  AlterIndexTable call per literally-named index, so a swallowed first
  failure cannot strand later drops (MySQL has no DROP INDEX IF EXISTS).
- B3: a real-PostgreSQL migration test proves both columns reach the wider
  length, a seeded value survives, and the claim-key unique index still
  rejects a duplicate (SQLSTATE 23505), exercising both widened columns.
- N1: the copy step returns early when the temp column is absent (resume).
- N3: re-check for duplicate claim keys before recreating the unique index
  so a duplicate written into the transient index-absent window on an
  autocommitting engine aborts with actionable guidance instead of an
  opaque failure; covered by a new SQLite throw-branch test.

Two independent Opus 5 rubber-duck reviews: GO (blockers were documentation
accuracy; corrected the reject-vs-truncate failure mode and the in-place
rebuild exception to the additive-only audit). Also fixes pre-existing
CS0105 duplicate-using errors blocking strict warnaserror builds.

claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mark PRODUCTION-READINESS C2 done (widening only) with a progress-log
entry citing commit 3d5865e and CI gate refs, and amend PLAN-3 W16.7 to
note the column-sizing half landed while the heartbeat-timestamp
relocation remains deferred.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sure and saturation metric

Replace the magic 1000-event channel bound and TryWrite-drop behavior in the
Asterisk real-time voice listener with genuine, configurable backpressure plus
a saturation metric, per PRODUCTION-READINESS D1 (was W12.11).

- AsteriskRealtimeIngestionWriter: await Writer.WriteAsync with a bounded
  RealtimeEventBackpressureTimeout wait (default 5s); emit
  asterisk.realtime.ingestion.saturated (meter CrestApps.OrchardCore.Asterisk)
  on the first wait of a saturation episode; reset the episode only when the
  buffer fully drains.
- Validated options: RealtimeEventBufferCapacity (default 1000, upper-bounded
  by AsteriskConstants.MaxRealtimeEventBufferCapacity = 100000) and
  RealtimeEventBackpressureTimeout, both enforced in Startup validators.
- AsteriskRealtimeIngestionDrainer: progress-based and hard-bounded
  post-disconnect drain (max(backpressure timeout, 30s)); a healthy-but-slow
  dispatcher finishes, a stalled one is cancelled and abandoned, with abandoned
  events falling to the existing coalesced, pointer-driven reconciliation.
- Reconnect backoff decays via a Stopwatch measuring receive uptime captured
  before the finally-drain and started after reconciliation, so drain/reconcile
  time is not counted as healthy uptime.
- ari.conf sets [general] websocket_write_timeout=10000 (ms) so client
  backpressure exceeds the configured window and reaches the provider as real
  flow control.
- Docs (production-support.md, runbooks.md, changelog v2.0.0) state the
  websocket_write_timeout precondition, the bounded-drain caveat for a dispatch
  wedged in non-cancellable tenant-scope work, and that the stream is not
  lossless under any configuration. W12.13 durable event journal stays out of
  scope (Track B).

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a cassette-backed contract test proving the Asterisk
ITelephonyCallStateProvider — the surface reconciliation depends on
(ProviderCallStateSynchronizationService / TelephonyInteractionSynchronizationService
dispatch on a runtime `is ITelephonyCallStateProvider` check) — issues a live
ARI channel lookup, per PRODUCTION-READINESS D2 (was W12.4).

- AsteriskCallStateReconciliationContractTests drives the production provider
  against the pinned Asterisk release's recorded ARI channel
  (Cassettes/Asterisk/22.10.1/rest/responses.json, GET channels/{channelId})
  and asserts it issues a live GET channels/{callId} and parses the recorded
  payload into TelephonyCallLookupResult (Found, CallState.Connected from "Up",
  From/To from the recorded caller/connected numbers). A second test proves a
  live 404 reconciles to Found=false; a third pins that AsteriskTelephonyProvider
  still implements ITelephonyCallStateProvider so reconciliation cannot silently
  skip ARI. channelId/state/numbers are read from the recorded body, so an
  Asterisk upgrade that changes the channel shape also fails here.
- Add reusable AsteriskContractCassettes.TryReadRecordedRestResponse(...) to read
  a recorded REST response from the pinned release's rest/responses.json.
- Document in production-support.md that reconciliation's restored state is read
  from live ARI and is pinned by this contract test.

The exit criterion — the test fails if the provider stops querying ARI — was
mutation-verified by the independent reviewer: short-circuiting GetCallStateAsync
to return the cassette values without issuing the ARI GET fails both liveness
tests. The transition-table half (illegal-edge rejection) was already covered by
AggregateLifecycleTableTests / AggregateStateTransitionTests.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the minimum operational signal set and a written voice-listener
handover/rollback procedure for the Contact Center production-readiness
plan (item D3 / wave W15.4).

Signals:
- Two Asterisk meter counters (asterisk.realtime.connected,
  asterisk.realtime.reconnect_attempted) recorded by
  AsteriskRealtimeVoiceListener on connect and before each reconnect
  backoff.
- Two store-count gauge health checks surfacing live counts as
  HealthCheckResult.Data: contactcenter-active-calls (EndedUtc == null),
  registered by the base Area feature, and contactcenter-queue-backlog
  (Status == Waiting), registered by the Queues feature. Each is
  registered by the feature that owns its store so neither throws on a
  partially enabled tenant. Counts are health-data rather than
  ObservableGauge metrics because a gauge callback is synchronous with
  no ambient tenant scope and a process-global count is wrong across
  tenants/nodes.

Runbook:
- New "Voice listener handover and rollback" section in runbooks.md,
  linked from the Rolling and Blue-green strategies and from
  production-support.md. It states the single-active-process constraint,
  prohibits overlapping deployments, frames the handover as a bounded
  interruption (no zero-downtime claim), and documents the honest fate
  of live calls during the gap: bridged media keeps flowing while
  control pauses, Stasis-parked channels with no dialplan continuation
  are stranded, and inbound calls whose StasisStart was missed are not
  recoverable by the store-driven reconciler.

Tests: metric contract test, a deterministic real-listener reconnect
wiring test (closed loopback port), both health-check tests, and updated
health-endpoint registration assertions. Public API baselines updated.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
E1 CRM/omnichannel production-readiness review. Fixes the one real
high-severity defect found (S1) in the automated-activities background
processor and the review regressions surfaced during independent review.

AutomatedActivitiesProcessorBackgroundTask:
- Keyset pagination: the page query previously combined a moving
  DocumentId cursor with an increasing OFFSET skip, advancing the window
  twice per batch and silently skipping every other page of due
  activities. Now pure keyset pagination.
- Cross-node double-send: commit per batch under a lease raised
  90s -> 600s (above the 5-min schedule interval), plus a wall-clock
  budget (60% of the lease) checked per item in both loops so a
  still-running node stops well inside its lease. OrchardCore does not
  cancel DoWorkAsync on lease expiry, so the per-item budget (not the
  per-batch commit) is what prevents an alive node handing an
  uncommitted backlog to a peer; a crashed node re-sends at most one
  in-flight batch.
- Failure transition: a caught StartAsync exception now increments a new
  document-only ProcessingAttempts counter, reschedules with backoff, and
  marks Failed at the cap, so a permanently failing activity leaves the
  due set instead of occupying a bounded slot forever. ProcessingAttempts
  is deliberately separate from the routing-owned, indexed, reported
  Attempts field (which the contact-center work-state projector
  overwrites) so it cannot be reset or corrupt reports/UI.
- Expiry pass: restricts the query to subject types whose flow defines a
  no-response timeout (computed once per run), so no-timeout
  conversations never enter the candidate set - no head-of-line stall and
  no sentinel written into the user-visible ScheduledUtc.
- Excludes OperationCanceledException so shutdown/recycle is not counted
  as a failure.

ActivitiesController: admin re-queue paths reset ProcessingAttempts when
re-arming an activity to its initial due status.

TwilioWebhookEndpoint: validate the request signature via the shared,
tested TwilioEventGridEndpoint.IsRequestValid helper (honours site
BaseUrl and reverse-proxy path base, returns 403 not 500 on a missing
signature header) instead of an inline raw-URL build with .First().

Tests: new AutomatedActivitiesProcessorBackgroundTaskTests covering
exactly-once pagination (teeth-verified against the Skip regression),
failure reschedule across two invocations without re-attempt and without
touching Attempts (teeth-verified), and the expiry pre-filter path
(teeth-verified). Strict warnaserror builds 0 warnings; targeted
Omnichannel + Twilio + migration-guard suites pass.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
E2 operator-documentation truthfulness pass over src/CrestApps.Docs,
verifying every operator claim against the shipped code across the five
scope items: module docs, the single-node-distributed reference
deployment (incl. the single-active-process constraint), the
recording-is-mono statement, the emergency-calling scope + operator
warning, and the data-residency statement.

- omnichannel/management.md: new "Data at rest and privacy" section
  discloses OmnichannelMessage.Content/CustomerAddress/ServiceAddress
  are stored plaintext (and projected plaintext into
  OmnichannelMessageIndex), contrasts the encrypted recording media
  store, and records no-automated-subject-erasure as a GA blocker.
- contact-center/index.md: recording is conversation-bridge mixed mono
  WAV; monitor/whisper not captured, barge is captured.
- contact-center/voice-routing.md: rewrote the emergency :::danger
  admonition to disclose ExternalDestinationPolicy denial is server-side
  only and the agent soft-phone bypasses it; bare short codes are
  refused by the E.164 form and minimum-length gate. Qualified the
  external-transfer catalog bullet: the orchestrated transfer path is
  not yet wired to an agent-facing surface, so the catalog constrains no
  transfer an agent can initiate today; soft-phone transfer is the only
  live path.
- contact-center/production-support.md: new "Data residency" table
  (tenant DB, recording store, Asterisk host transient file, Redis
  backplane, Twilio/ACS SMS+email, DialPad voice, AI provider), a
  governance cross-ref noting Interaction is deleted outright at
  retention while OmnichannelMessage has no retention window, a
  node-census clause (single-active only mitigated by the lock, not
  runtime-verified), and a fix for a pre-existing broken
  #backup-and-restore anchor.
- changelog/v2.0.0.md: recorded the operator-facing truths.
- Updated both Contact Center ledgers; annotated PLAN-3 W7.3/W7.5.

Docs build clean (zero broken anchors). Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ff by default

Make base-voice deployment verification an explicit, fail-closed release gate
and stop capturing recordings until an operator proves the media path.

Core behavior:
- ContactCenterRecordingSettings.RecordingEnabled now defaults to false; a fresh
  tenant records nothing until an operator enables it after acceptance passes.
  The Recording capability stays advertised; Monitor/Whisper/Barge stay
  advertised and enabled (implemented, in-scope) — only the recording policy
  defaults off.
- New BaseVoiceVerificationOptions (AudioVerificationAcknowledged +
  AudioVerificationEvidenceReference) bound from
  CrestApps_ContactCenter:BaseVoiceVerification, with a ValidateOnStart validator
  that rejects on every host when acknowledgment carries no retained evidence, so
  acknowledgment can never be a bare boolean flip.
- New contactcenter-base-voice-verification readiness check (registered by the
  Voice feature, tagged contactcenter-ready) fails closed in production and only
  warns outside it — the second deliberate exception, after topology, to the
  "readiness must differ between nodes" rule, because an unverified media path is
  fixed infrastructure that no amount of waiting repairs.
- BaseVoiceVerificationStartupCheck logs Critical in production / Warning
  otherwise while the path is unverified.

Pre-existing fixes surfaced by G1 validation (not G1 surface):
- DI cycle: enabling Recording closed the loop publisher -> outbox -> handlers ->
  RecordingMediaDeletionHandler -> publisher, so the container refused to
  construct the publisher (and everything depending on it) the moment Recording
  was enabled. The handler now takes IServiceProvider and resolves the publisher
  lazily in HandleAsync (the repo's blessed pattern, cf. ReofferVoiceWorkHandler),
  breaking the cycle while preserving the confirmed-deletion receipt. This is a
  breaking change to the handler's public constructor signature. The
  feature-activation dependency audit now passes for Recording.
- The two Omnichannel public-API baselines (Omnichannel.Core,
  Omnichannel.Managements) are unrelated E1 catch-up: their approved.txt were
  never regenerated after commit 00f7c51, not G1 surface. The two ContactCenter
  baselines are G1's new surface.

Docs/ledger:
- production-support.md gains the base-voice acceptance procedure (direct-ICE,
  forced-TURN, restart-drain, dependency-failure, capacity floor), an evidence
  template, and the config to declare the result; it states explicitly that no
  automated test exercises the browser<->Asterisk WebRTC path (the scaffolded
  AsteriskBrowserAudioE2ETests proof is [Skip]).
- PLAN-3 capability table (recording, monitor/whisper/barge rows) reconciled to
  match shipped behavior instead of "conditional on audio proof passing
  unskipped". PRODUCTION-READINESS G1 box stays open: the live reference
  execution + retained evidence is the outstanding operator step (PLAN-3 W5.5/W5.7
  remain open).

Known pre-existing red gate left untouched (out of G1 scope): Governance
LedgerEvidenceTests fails on PLAN-3 W13, whose gate:gate-crm-review-closed is a
review-type gate that cannot cite a workflow.yml#job. Resolving it is an
E1/governance-policy decision; a dishonest CI-job annotation was deliberately not
added.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes the planning/tracking scaffolding that only supported the
multi-phase Contact Center build and no longer belongs in the shipped
product, per PRODUCTION-READINESS.md section 7 (T1).

Deleted:
- .playwright-mcp/ (30 captured page dumps; now .gitignored).
- The four superseded plan docs: PLAN.md, PLAN-2-SINGLE-NODE-COMPLETION.md,
  PLAN-3-PRODUCTION-SINGLE-NODE-DISTRIBUTED.md, R0-BASELINE.md.
- The three pure release-tracking JSON ledgers: pr-test-control-matrix.v1.json,
  r0b-harness-dependency-ledger.v1.json, service-objectives.v1.json.
- The plan-parsing meta-tests that only existed to validate those documents:
  the Governance LedgerEvidenceTests + WorkflowJobCatalog, and the ContactCenter
  PR-test-control-matrix / R0b-harness / service-objectives tests.
- The two docs pages that only described the removed ledgers:
  pr-test-control-matrix.md, service-objectives.md.

Kept (runtime/architecture-contract ledgers ordinary tests consume):
support-matrix.v1.json, feature-dependency-violations.v1.json,
feature-lifecycle-contracts.v1.json and their consuming tests. Only the one
orphaned, vacuous EveryKnownViolation_OwnsAControlMatrixGate method (empty
knownViolations) was excised from ContactCenterFeatureDependencyArchitectureTests.

Also updated .github/copilot-instructions.md and the docs (index,
production-support, public-api-surface, runbooks, changelog v2.0.0) to drop
references to the removed files, reworded three self-contained test skip
reasons/comments, and added .playwright-mcp/ to .gitignore.

This removes the LedgerEvidenceTests.AuthoritativeLedger_BacksEveryCompletedItemWithARealCiJob
meta-test that was the sole failing test (W13, a review-type gate that
structurally cannot name a CI job) on PR #501, turning Build & Test green.

Validation: full main test project 3733 passed / 0 failed / 1 skipped;
strict -warnaserror build 0 warnings; docs build clean. Independent
claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Split the 718-line VoiceContactCenterCallRouter into three
single-responsibility services with no behavior change:

- InboundVoiceCallProcessor (IInboundVoiceCallProcessor) owns inbound
  routing: distributed-lock + work-lease orchestration, flow/contact/
  queue resolution, activity + interaction creation, terminalization.
- VoiceQueueOfferService (IVoiceQueueOfferService) owns OfferNextAsync
  (reserve next agent for a queue and offer the queued call). Named to
  disambiguate from the pre-existing agent-centric
  IQueuedVoiceWorkOfferService.
- VoiceContactCenterCallRouter is now a 114-line facade implementing
  both public interfaces (IVoiceContactCenterCallRouter +
  IInboundVoiceService), delegating inbound and offer while keeping
  outbound in place.

All method bodies moved verbatim. The shared
IContactCenterFeatureWorkManager singleton preserves the nested lease
semantics of the inbound->offer path. Three static architecture/
retention guards were repointed to InboundVoiceCallProcessor.cs where
the guarded code now lives (scope-executor, _settlementWriters
["Interaction"], aggregate-lifecycle scanned-files spot-check). No
public-API baseline change (the concrete router is not baselined; the
two interfaces are untouched).

Full main test project 3733 passed / 0 failed / 1 skipped; strict
warnaserror build 0 warnings.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…(F2)

Replace 7 structurally-identical catalog-CRUD admin controllers (Queues,
QueueGroups, EntryPoints, DialerProfiles, AgentStateReasonCodes,
BusinessHoursCalendars, Skills) with thin routing shells over a new generic
base ContactCenterCatalogController<TModel>, removing ~699 lines of duplicated
list/filter/create/edit/delete orchestration with no behavior change.

Routes, route names, permissions, HTTP verbs, localized S[...]/H[...] literals,
the nameof(Model) validation prefix, and view resolution are all preserved.
AgentEntitlementsController is intentionally not migrated (bespoke user-binding
CRUD).

Also harden ValidationOwnershipArchitectureTests to follow controller
inheritance so the catalog-editor validation gate inspects the base file's
write sites instead of passing vacuously once they moved off the derived
controllers (negative-probe verified). Tighten the shared H/S fields to
private protected to preserve the originals' internal reach.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tor + Asterisk partials)

Behavior-preserving decomposition of the two remaining oversized files.

EnterpriseInteractionReportProvider (1554 lines): extract the pure, stateless
metric-calculation logic into a new internal static InteractionMetricsCalculator
(Reports/Services/) and relocate the InteractionMetrics / QueueServiceLevelMetrics
result models from nested types into top-level internal sealed types
(Reports/Models/). All moves are byte-identical; six calculator helpers widen
private static -> public static so `using static` can import them (container is
internal, so the assembly-visible surface is unchanged). The provider keeps all
instance-stateful Build*/RunAsync work and gains `using static` so its ~30 bare
call sites resolve unchanged. Two test call sites retargeted.

AsteriskContactCenterVoiceProvider (2412 lines): implements seven capability
interfaces but is DI-registered once and consumers cast the single instance, so
it must stay one compiled type. Split by capability into partial classes:
internal sealed class -> internal sealed partial class, with .Recording/.Transfer/
.Conference/.AttendedTransfer/.Monitoring partials. Member multiset preserved
exactly (64 -> 64); interface list untouched so all capability casts are
unaffected.

Strict -warnaserror builds 0/0 (both modules + tests); full main test project
3733 passed / 0 failed / 1 skipped (gate:pr_ci.yml#build_test).

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
G1 (prove base voice path; ship recording off by default) was implemented,
reviewed to a round-2 independent claude-opus-5 GO, and committed in 2991844.
Tick its checkbox so the ledger reflects the shipped state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…) complete

T3: verified src/CrestApps.Docs matches shipped code; changelog v2.0.0 aligns
with VersionPrefix; F1-F3 are internal refactors needing no doc change; docs
build clean with no broken links.

T4: CrestApps.Aspire.AppHost boots cleanly (dashboard up, Asterisk/Coturn/Redis
healthy, OrchardCore CMS serves HTTP 200). Ollama stays Created due to macOS
Docker lacking GPU passthrough, which is intentionally non-blocking.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… + secret unprotect)

Independent claude-opus-5 PR-wide deep-dive returned GO with no blocking or
should-fix findings; these are the two surfaced nits.

1. Pin the canonical provider-name column width. The same value was stored at
   WithLength(128) in InteractionIndex/CallSessionIndex but WithLength(100) in
   ProviderCommandIndex/ProviderWebhookInboxMessageIndex. Promote a single
   ContactCenterConstants.ProviderNameLength = 128 and reference it from all four
   migrations so no table can truncate a value another accepts. Regenerate the
   ContactCenter.Abstractions public-API baseline for the new constant.

2. Unprotect AsteriskResolvedSettings.PjsipRealtimeConnectionString at the two
   tenant-settings construction sites (AsteriskTelephonyProvider,
   AsteriskSoftPhoneRegistrationConfigContributor) so it is consistent with the
   sibling secrets (TurnSharedSecret/Password) in the same initializers and with
   the credential store's own handling. The value is never emitted to the browser
   and resolved settings are never persisted, so there is no plaintext-at-rest or
   leak regression; the unprotect helpers are null/empty-safe.

Full-solution strict -warnaserror build 0/0; full unit suite 3733 passed /
0 failed / 1 skipped.

Independent claude-opus-5 GO.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Accidentally staged with the T2 nit-fix commit; it is a transient review-agent
scratch file and does not belong in the repository.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants